From 7ae7f2d6e68417e25979cded6181e72d10ba8643 Mon Sep 17 00:00:00 2001
From: Christoph Berg <myon@debian.org>
Date: Fri, 24 Apr 2026 18:16:31 +0200
Subject: [PATCH v8 3/3] Introduce contributor badges

Previously only recognized contributors would be listed on the
contributor profiles page. In order to be able to recognize more people,
we introduce contributor badges. A badge is an award for a contribution
to PostgreSQL like "PostgreSQL 18 Contributor" (as per the contributor
list in the release notes), "PGConf.EU 2025 Speaker", "London PG Meetup
Organizer" or anything else that contributed to PostgreSQL in a positive
way. Ecosystem projects are invited to create badges, e.g. "Patroni
Contributor" or "Talking Postgres Guest".

Technically, we introduce a new "Badge" Django object in the
contributors namespace, to be administrated by the contributors team.
The existing "submit a new xxx" workflow is extended to include badges
which are tied to organisations like the existing other objects. New
badges are subject to moderation, but once approved, the submitting
organisation can edit the badge, most importantly to add more badge
holders.

Once an individual has been awarded a badge, they can create a profile
page from their account page, and optionally supply a bio. This extends
the existing "Contributor" object in Django, but the page is put under
the /community/people/ tree to separate it from the existing
/community/contributors/ page which will be kept as the curated list of
recognized Major and Significant Contributors. (contributor.ctype is now
NULLable, and NULL entries will not show up on the contributors page. If
a user later contributes more, we can simply set the field to the
desired recognized contributors status.)

Badges are represented by pictures, preferably square and 150x150,
stored in a ImageBinaryField.

The badges workflow can be summarized as follows:

- An individual can submit the form to make an Organization
- A pgweb moderator can approve the organization
- An Organization manager can submit the form to make a new Badge
  including contact info for requesting badge holder status, and a badge
  image
- A pgweb moderator can approve the new badge
- An Organization manager can add Badge holders (before or after
  approval of the Badge but the holders won't show anywhere until
  approved)

- A User can request a badge to become a Badge holder by emailing the
  badge contact info on the Badge page
- A User who is a Badge holder can check a box to get a profile page
  (makes a Contributor table entry, but we do not formally call them
  contributors in the pages)
- The user can fill in their contributors bio to have it show up in
  /community/people/<username>/.

New Pages introduced:
community/people/<username>/
  -- profile page

community/people/
  -- all badge holders and all badges

community/badge/<number>/
  -- badge info (image, description text, URL)
  -- badge holders
  -- badge request contact email address (hopefully if they added it)

community/badge/<number>/image/
  -- the badge image

Author: Melanie Plageman <melanieplageman@gmail.com>
Author: Christoph Berg <myon@debian.org>
---
 media/css/main.css                            |  22 +++
 pgweb/account/forms.py                        |   2 +-
 pgweb/account/urls.py                         |   2 +-
 pgweb/account/views.py                        |  16 +-
 pgweb/contributors/admin.py                   |  19 +-
 pgweb/contributors/forms.py                   | 106 ++++++++++
 pgweb/contributors/migrations/0001_initial.py |   2 +-
 ..._ctype_alter_contributor_email_and_more.py | 182 ++++++++++++++++++
 pgweb/contributors/models.py                  |  74 ++++++-
 pgweb/contributors/views.py                   |  51 ++++-
 pgweb/core/forms.py                           |   2 +-
 pgweb/urls.py                                 |   6 +-
 pgweb/util/contexts.py                        |   2 +
 pgweb/util/moderation.py                      |   3 +-
 templates/account/index.html                  |   7 +-
 templates/account/userprofileform.html        |  22 ++-
 templates/base/form_contents.html             |   2 +-
 templates/contributors/badge.html             |  49 +++++
 templates/contributors/list.html              |  29 +--
 templates/contributors/people.html            |  36 ++++
 templates/contributors/profile.html           |  57 ++++++
 21 files changed, 657 insertions(+), 34 deletions(-)
 create mode 100644 pgweb/contributors/forms.py
 create mode 100644 pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py
 create mode 100644 templates/contributors/badge.html
 create mode 100644 templates/contributors/people.html
 create mode 100644 templates/contributors/profile.html

diff --git a/media/css/main.css b/media/css/main.css
index 7e9ebf18..2e823553 100644
--- a/media/css/main.css
+++ b/media/css/main.css
@@ -1737,6 +1737,28 @@ table.contributor-table thead tr th {
   width: 50%;
 }
 
+.badges-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+  gap: 4px;
+}
+.badges-cell {
+  margin: 0;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+}
+.badges-cell img {
+  width: 100%;
+  aspect-ratio: 1 / 1;
+  object-fit: cover;
+  display: block;
+}
+.badges-cell figcaption {
+  text-align: center;
+  padding: 4px;
+}
+
 /* News archive */
 #pgContentWrap span.newstag {
     white-space: nowrap;
diff --git a/pgweb/account/forms.py b/pgweb/account/forms.py
index 1d0d8907..dba18aff 100644
--- a/pgweb/account/forms.py
+++ b/pgweb/account/forms.py
@@ -173,7 +173,7 @@ class UserForm(forms.ModelForm):
 class ContributorForm(forms.ModelForm):
     class Meta:
         model = Contributor
-        exclude = ('ctype', 'user', ) # these fields are not user-editable
+        exclude = ('ctype', 'user', )  # these fields are not user-editable
 
 
 class AddEmailForm(forms.Form):
diff --git a/pgweb/account/urls.py b/pgweb/account/urls.py
index 12ad5955..a5621d22 100644
--- a/pgweb/account/urls.py
+++ b/pgweb/account/urls.py
@@ -26,7 +26,7 @@ urlpatterns = [
 
     # Submitted items
     re_path(r'^(?P<objtype>news)/(?P<item>\d+)/(?P<what>submit|withdraw)/$', pgweb.account.views.submitted_item_submitwithdraw),
-    re_path(r'^(?P<objtype>news|events|products|organisations|services)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
+    re_path(r'^(?P<objtype>news|events|products|organisations|services|badges)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
     re_path(r'^organisations/confirm/([0-9a-f]+)/$', pgweb.account.views.confirm_org_email),
 
     # Markdown preview (silly to have in /account/, but that's where all the markdown forms are so meh)
diff --git a/pgweb/account/views.py b/pgweb/account/views.py
index cbc0bbf9..936b2f23 100644
--- a/pgweb/account/views.py
+++ b/pgweb/account/views.py
@@ -35,7 +35,7 @@ from pgweb.news.models import NewsArticle, NewsPostingEmbargo
 from pgweb.events.models import Event
 from pgweb.core.models import Organisation, UserProfile, ModerationNotification
 from pgweb.core.models import OrganisationEmail
-from pgweb.contributors.models import Contributor
+from pgweb.contributors.models import Contributor, Badge
 from pgweb.downloads.models import Product
 from pgweb.profserv.models import ProfessionalService
 
@@ -124,6 +124,11 @@ objtypes = {
         'submit_header': '<h3>Submit organisation</h3>Before submitting a new Organisation, please verify on the list of <a href="/account/orglist/">current organisations</a> if the organisation already exists. If it does, please contact the manager of the organisation to gain permissions.',
         'editapproved': True,
     },
+    'badges': {
+        'title': 'contributor badge',
+        'objects': lambda u: Badge.objects.filter(org__managers=u),
+        'editapproved': True,
+    },
 }
 
 
@@ -141,11 +146,13 @@ def profile(request):
     can_change_email = (request.user.password != OAUTH_PASSWORD_STORE)
 
     # We may have a contributor record - and we only show that part of the
-    # form if we have it for this user.
+    # form if we have it for this user. If the user has any badges awarded,
+    # offer them to create the contributor record.
     try:
         contrib = Contributor.objects.get(user=request.user.pk)
     except Contributor.DoesNotExist:
         contrib = None
+    badges = Badge.objects.filter(approved=True, holders=request.user.pk)
 
     contribform = None
 
@@ -177,7 +184,9 @@ def profile(request):
                 log.info("User {} changed primary email from {} to {}".format(user.username, oldemail, user.email))
 
             profileform.save()
-            if contrib:
+            if badges and 'create_contributor_profile' in request.POST and request.POST['create_contributor_profile']:
+                contrib = Contributor.objects.create(user=user, firstname=user.first_name, lastname=user.last_name, email=user.email)
+            elif contrib:
                 contribform.save()
             if secondaryemailform.cleaned_data.get('email1', ''):
                 sa = SecondaryEmail(user=request.user, email=secondaryemailform.cleaned_data['email1'], token=generate_random_token())
@@ -210,6 +219,7 @@ def profile(request):
         'secondaryemailform': secondaryemailform,
         'secondaryaddresses': secondaryaddresses,
         'secondarypending': any(not a.confirmed for a in secondaryaddresses),
+        'badges': badges,
         'contribform': contribform,
     })
 
diff --git a/pgweb/contributors/admin.py b/pgweb/contributors/admin.py
index 33d4d05f..9bedc4cd 100644
--- a/pgweb/contributors/admin.py
+++ b/pgweb/contributors/admin.py
@@ -1,7 +1,8 @@
 from django import forms
 from django.contrib import admin
+from django.db.models import Count
 
-from .models import Contributor, ContributorType
+from .models import Contributor, ContributorType, Badge, Badgeholder
 
 
 class ContributorAdminForm(forms.ModelForm):
@@ -22,5 +23,21 @@ class ContributorAdmin(admin.ModelAdmin):
     search_fields = ('firstname', 'lastname', 'user__username',)
 
 
+class BadgeAdmin(admin.ModelAdmin):
+    list_display = ('__str__', 'holders_count', 'approved', 'org',)
+    list_filter = ('approved', 'org',)
+    autocomplete_fields = ['holders', ]
+
+    def holders_count(self, obj):
+        return obj.holders_count
+
+    def get_queryset(self, request):
+        queryset = super().get_queryset(request)
+        queryset = queryset.annotate(holders_count=Count("holders"))
+        return queryset
+
+
 admin.site.register(ContributorType)
 admin.site.register(Contributor, ContributorAdmin)
+admin.site.register(Badge, BadgeAdmin)
+admin.site.register(Badgeholder)
diff --git a/pgweb/contributors/forms.py b/pgweb/contributors/forms.py
new file mode 100644
index 00000000..a2bc587f
--- /dev/null
+++ b/pgweb/contributors/forms.py
@@ -0,0 +1,106 @@
+from django import forms
+from django.forms import ValidationError
+from django.conf import settings
+
+from pgweb.core.models import Organisation
+from .models import Contributor, Badge, Badgeholder
+from django.contrib.auth.models import User
+
+from pgweb.util.middleware import get_current_user
+from pgweb.mailqueue.util import send_simple_mail
+
+from datetime import date
+
+
+class UserModelMultipleChoiceField(forms.ModelMultipleChoiceField):
+    def label_from_instance(self, obj):
+        contributor = Contributor.objects.filter(user=obj.pk)
+        if contributor:
+            return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}>"
+        else:
+            return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}> (not a contributor yet)"
+
+
+class BadgeForm(forms.ModelForm):
+    form_intro = 'Contributor badges acknowledge people contributing time to the PostgreSQL project and the ecosystem around it. If you manage an organisation that gives people the opportunity to contribute (like volunteering or speaking at a conference, writing code for an extension, translating messages, helping others use PostgreSQL, organise the community, ...), you can issue a badge to acknowledge these contributions.'
+
+    add_holder = forms.CharField(required=False, help_text="Enter email addresses of postgresql.org user accounts to award the badge to. Separate multiple addresses with whitespace.")
+    retire_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Current badge holders", help_text="Select one or more users to retire.")
+    remove_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Retired badge holders", help_text="Select one or more users to remove. To un-retire a user, add their email address again in the first field.")
+
+    fieldsets = [
+        {
+            'id': 'general',
+            'legend': 'Contributor Badge',
+            'fields': ['org', 'badge', 'description', 'url', 'imagedata', 'contact', ],
+        },
+        {
+            'id': 'holders',
+            'legend': 'Badge Holders',
+            'fields': ['add_holder', 'retire_holder', 'remove_holder'],
+        },
+    ]
+
+    class Meta:
+        model = Badge
+        exclude = ('approved', 'sortorder', 'holders',)
+
+    def __init__(self, *args, **kwargs):
+        super(BadgeForm, self).__init__(*args, **kwargs)
+        if self.instance and self.instance.pk:
+            self.fields['retire_holder'].queryset = self.instance.holders.filter(badgeholder__date_retired__isnull=True)
+            self.fields['remove_holder'].queryset = self.instance.holders.filter(badgeholder__date_retired__isnull=False)
+        else:
+            del self.fields['add_holder']
+            del self.fields['retire_holder']
+            del self.fields['remove_holder']
+            # remove the holders fieldset
+            self.fieldsets = [fs for fs in self.fieldsets if fs['id'] != 'holders']
+
+    def clean_add_holder(self):
+        if self.cleaned_data['add_holder']:
+            for u in self.cleaned_data['add_holder'].split():
+                # something was added - let's make sure the user exists
+                try:
+                    User.objects.get(email=u.lower())
+                except User.DoesNotExist:
+                    raise ValidationError("User with email %s not found" % u)
+
+        return self.cleaned_data['add_holder']
+
+    def save(self, commit=True):
+        badge = super(BadgeForm, self).save(commit=False)
+
+        ops = []
+
+        if 'add_holder' in self.cleaned_data and self.cleaned_data['add_holder']:
+            for u in self.cleaned_data['add_holder'].split():
+                user = User.objects.get(email=u.lower())
+                holder = Badgeholder.objects.get_or_create(user=user, badge=badge, awarded_by=get_current_user())[0]
+                if holder.date_retired:  # un-retire them
+                    holder.date_retired = None
+                    holder.save()
+                    ops.append('Un-retired badge holder {}'.format(user.username))
+                else:
+                    ops.append('Added badge holder {}'.format(user.username))
+        if 'retire_holder' in self.cleaned_data and self.cleaned_data['retire_holder']:
+            for toretire in self.cleaned_data['retire_holder']:
+                Badgeholder.objects.filter(user=toretire).update(date_retired=date.today())
+                ops.append('Retired badge holder {}'.format(toretire.username))
+        if 'remove_holder' in self.cleaned_data and self.cleaned_data['remove_holder']:
+            for toremove in self.cleaned_data['remove_holder']:
+                badge.holders.remove(toremove)
+                ops.append('Removed badge holder {}'.format(toremove.username))
+
+        if ops:
+            send_simple_mail(
+                settings.NOTIFICATION_FROM,
+                settings.NOTIFICATION_EMAIL,
+                "{0} modified {1}".format(get_current_user().username, badge),
+                "The following changes were made to {}:\n\n{}".format(badge, "\n".join(ops))
+            )
+
+        return badge
+
+    def filter_by_user(self, user):
+        self.fields['org'].queryset = Organisation.objects.filter(managers=user, approved=True)
diff --git a/pgweb/contributors/migrations/0001_initial.py b/pgweb/contributors/migrations/0001_initial.py
index c7d1bd17..244e2e49 100644
--- a/pgweb/contributors/migrations/0001_initial.py
+++ b/pgweb/contributors/migrations/0001_initial.py
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
                 ('company', models.CharField(max_length=100, null=True, blank=True)),
                 ('companyurl', models.URLField(max_length=100, null=True, verbose_name='Company URL', blank=True)),
                 ('location', models.CharField(max_length=100, null=True, blank=True)),
-                ('contribution', models.TextField(null=True, blank=True, help_text='This description is currently used for major contributors only')),
+                ('contribution', models.TextField(null=True, blank=True, help_text='Describe what you did in the PostgreSQL community')),
             ],
             options={
                 'ordering': ('lastname', 'firstname'),
diff --git a/pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py b/pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py
new file mode 100644
index 00000000..ed6734d5
--- /dev/null
+++ b/pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py
@@ -0,0 +1,182 @@
+# Generated by Django 5.2.16 on 2026-07-29 12:45
+
+import django.db.models.deletion
+import pgweb.util.fields
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("contributors", "0003_make_email_nullable"),
+        ("core", "0007_usersubmissions"),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="contributor",
+            name="ctype",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to="contributors.contributortype",
+                verbose_name="Contributor Type",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="contributor",
+            name="email",
+            field=models.EmailField(blank=True, max_length=254, null=True),
+        ),
+        migrations.AlterField(
+            model_name="contributor",
+            name="user",
+            field=models.OneToOneField(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+        migrations.CreateModel(
+            name="Badge",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("approved", models.BooleanField(default=False)),
+                (
+                    "badge",
+                    models.CharField(
+                        help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".',
+                        max_length=32,
+                        unique=True,
+                    ),
+                ),
+                (
+                    "description",
+                    models.TextField(
+                        blank=True,
+                        help_text="What did the people do who contributed here?",
+                        null=True,
+                    ),
+                ),
+                (
+                    "url",
+                    models.URLField(
+                        blank=True,
+                        help_text="URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)",
+                        max_length=100,
+                        null=True,
+                        verbose_name="Contribution URL",
+                    ),
+                ),
+                (
+                    "imagedata",
+                    pgweb.util.fields.ImageBinaryField(
+                        blank=True,
+                        help_text="Square image, will be auto-scaled to 150×150. JPEG or PNG, up to 1 MB. When left blank, the Slony logo will be used.",
+                        max_length=1000000,
+                        null=True,
+                        verbose_name="Image",
+                    ),
+                ),
+                (
+                    "contact",
+                    models.CharField(
+                        blank=True,
+                        help_text="Contact address (email, URL, other) for people who want to be added as badge holder",
+                        max_length=100,
+                        null=True,
+                        verbose_name="Contact address",
+                    ),
+                ),
+                ("sortorder", models.IntegerField(blank=True, default=100, null=True)),
+                (
+                    "org",
+                    models.ForeignKey(
+                        help_text='If no organisations are listed here, please check the <a href="/account/orglist/">organisation list</a> and contact the organisation manager or <a href="mailto:webmaster@postgresql.org">webmaster@postgresql.org</a> if no managers are listed. If your organisation is not listed, you can <a href="/account/edit/organisations/">create it</a>.',
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="core.organisation",
+                        verbose_name="Organisation",
+                    ),
+                ),
+            ],
+            options={
+                "ordering": ("sortorder", "badge"),
+            },
+        ),
+        migrations.CreateModel(
+            name="Badgeholder",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("date_awarded", models.DateField(auto_now_add=True)),
+                ("date_retired", models.DateField(blank=True, null=True)),
+                (
+                    "awarded_by",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="awarded_by",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+                (
+                    "badge",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="contributors.badge",
+                    ),
+                ),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+            options={
+                "ordering": (
+                    "badge__sortorder",
+                    "badge",
+                    "user__last_name",
+                    "user__first_name",
+                ),
+            },
+        ),
+        migrations.AddField(
+            model_name="badge",
+            name="holders",
+            field=models.ManyToManyField(
+                blank=True,
+                through="contributors.Badgeholder",
+                through_fields=("badge", "user"),
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="badgeholder",
+            constraint=models.UniqueConstraint(
+                fields=("user", "badge"), name="unique_user_badge"
+            ),
+        ),
+    ]
diff --git a/pgweb/contributors/models.py b/pgweb/contributors/models.py
index 8f521785..11589d36 100644
--- a/pgweb/contributors/models.py
+++ b/pgweb/contributors/models.py
@@ -1,5 +1,9 @@
 from django.db import models
 from django.contrib.auth.models import User
+from pgweb.core.models import Organisation
+from pgweb.core.text import ORGANISATION_HINT_TEXT
+from pgweb.util.moderation import TwostateModerateModel
+from pgweb.util.fields import ImageBinaryField
 
 
 class ContributorType(models.Model):
@@ -19,22 +23,82 @@ class ContributorType(models.Model):
 
 
 class Contributor(models.Model):
-    ctype = models.ForeignKey(ContributorType, on_delete=models.CASCADE, verbose_name='Contributor Type')
+    ctype = models.ForeignKey(ContributorType,
+                              on_delete=models.CASCADE,
+                              verbose_name='Contributor Type', null=True, blank=True)
     firstname = models.CharField(max_length=100, null=False, blank=False)
     lastname = models.CharField(max_length=100, null=False, blank=False)
-    email = models.EmailField(null=False, blank=True)
+    email = models.EmailField(null=True, blank=True)
     company = models.CharField(max_length=100, null=True, blank=True)
     companyurl = models.URLField(max_length=100, null=True, blank=True, verbose_name='Company URL')
     location = models.CharField(max_length=100, null=True, blank=True)
     contribution = models.TextField(null=True, blank=True,
-                                    help_text='This description is currently used for major contributors only')
-    user = models.ForeignKey(User, null=True, blank=True, on_delete=models.CASCADE)
+                                    help_text='Describe what you did in the PostgreSQL community')
+    user = models.OneToOneField(User, null=True, blank=True, on_delete=models.CASCADE)
 
     send_notification = True
-    purge_urls = ('/community/contributors/', )
+    purge_urls = ('/community/contributors/', '/community/people/', '/community/badge/')
 
     def __str__(self):
         return "%s %s" % (self.firstname, self.lastname)
 
     class Meta:
         ordering = ('lastname', 'firstname',)
+
+
+class Badge(TwostateModerateModel):
+    org = models.ForeignKey(Organisation, null=False, blank=False, verbose_name="Organisation", help_text=ORGANISATION_HINT_TEXT, on_delete=models.CASCADE)
+    badge = models.CharField(max_length=32, null=False, blank=False, unique=True, help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".')
+    description = models.TextField(null=True, blank=True, help_text='What did the people do who contributed here?')
+    url = models.URLField(max_length=100, null=True, blank=True, verbose_name='Contribution URL', help_text='URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)')
+    imagedata = ImageBinaryField(max_length=1000000, resolution=(150, 150), auto_scale=True, blank=True, null=True, verbose_name="Image", help_text="Square image, will be auto-scaled to 150×150. JPEG or PNG, up to 1 MB. When left blank, the Slony logo will be used.")
+    contact = models.CharField(max_length=100, null=True, blank=True, verbose_name='Contact address', help_text='Contact address (email, URL, other) for people who want to be added as badge holder')
+    holders = models.ManyToManyField(User, through="Badgeholder", through_fields=("badge", "user"), blank=True)
+
+    sortorder = models.IntegerField(null=True, blank=True, default=100)
+
+    account_edit_suburl = 'badges'
+    moderation_fields = ['badge', 'description', 'url']
+    send_notification = True
+    purge_urls = ('/community/people/', '/community/badge/')
+
+    def verify_submitter(self, user):
+        return (len(self.org.managers.filter(pk=user.pk)) == 1)
+
+    def __str__(self):
+        return self.badge
+
+    @property
+    def title(self):
+        return self.badge
+
+    class Meta:
+        ordering = ('sortorder', 'badge')
+
+    @classmethod
+    def get_formclass(self):
+        from pgweb.contributors.forms import BadgeForm
+        return BadgeForm
+
+
+class Badgeholder(models.Model):
+    user = models.ForeignKey(User, on_delete=models.CASCADE)
+    badge = models.ForeignKey(Badge, on_delete=models.CASCADE)
+
+    date_awarded = models.DateField(auto_now_add=True)
+    date_retired = models.DateField(null=True, blank=True)
+    awarded_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="awarded_by")
+
+    purge_urls = ('/community/people/', '/community/badge/')
+
+    def __str__(self):
+        label = f"{self.badge} {self.user.first_name} {self.user.last_name} ({self.user})"
+        if self.date_retired:
+            label += " (retired)"
+        return label
+
+    class Meta:
+        ordering = ('badge__sortorder', 'badge', 'user__last_name', 'user__first_name')
+        constraints = [
+            models.UniqueConstraint(fields=["user", "badge"], name="unique_user_badge")
+        ]
diff --git a/pgweb/contributors/views.py b/pgweb/contributors/views.py
index 45b8551e..f439671c 100644
--- a/pgweb/contributors/views.py
+++ b/pgweb/contributors/views.py
@@ -1,6 +1,12 @@
+import hashlib
+
+from django.shortcuts import get_object_or_404
+from django.http import HttpResponse, HttpResponseNotModified, Http404
+
 from pgweb.util.contexts import render_pgweb
+from pgweb.util.image import get_image_contenttype_from_bytes
 
-from .models import ContributorType
+from .models import ContributorType, Contributor, Badge, Badgeholder
 
 
 def completelist(request):
@@ -8,3 +14,46 @@ def completelist(request):
     return render_pgweb(request, 'community', 'contributors/list.html', {
         'contributortypes': contributortypes,
     })
+
+
+def peoplelist(request):
+    people = Contributor.objects.prefetch_related('user')
+    badges = Badge.objects.filter(approved=True)
+    return render_pgweb(request, 'community', 'contributors/people.html', {
+        'people': people,
+        'badges': badges,
+    })
+
+
+def badge_view(request, badgeid):
+    badge = get_object_or_404(Badge, id=badgeid, approved=True)
+    # badge holders are users, but we want to show only users with a contributor object here
+    holders = Badgeholder.objects.filter(badge=badge, user__contributor__isnull=False). \
+        prefetch_related('user__contributor'). \
+        order_by('user__contributor__lastname', 'user__contributor__firstname')
+    return render_pgweb(request, 'community', 'contributors/badge.html', {
+        'badge': badge,
+        'holders': holders,
+    })
+
+
+def profile(request, username):
+    contributor = get_object_or_404(Contributor, user__username=username)
+    badges = Badgeholder.objects.filter(user=contributor.user, badge__approved=True)
+    return render_pgweb(request, 'community', 'contributors/profile.html', {
+        'contributor': contributor,
+        'badges': badges,
+    })
+
+
+def badge_image(request, badgeid):
+    badge = get_object_or_404(Badge.objects.only('id', 'imagedata'), id=badgeid, approved=True)
+    if not badge.imagedata:
+        raise Http404
+    etag = '"' + hashlib.md5(bytes(badge.imagedata)).hexdigest() + '"'
+    if request.headers.get('If-None-Match') == etag:
+        return HttpResponseNotModified()
+    return HttpResponse(
+        bytes(badge.imagedata),
+        content_type=get_image_contenttype_from_bytes(badge.imagedata[:8]),
+    )
diff --git a/pgweb/core/forms.py b/pgweb/core/forms.py
index 1609c780..7902e750 100644
--- a/pgweb/core/forms.py
+++ b/pgweb/core/forms.py
@@ -13,7 +13,7 @@ from pgweb.util.misc import send_template_mail, generate_random_token
 
 class OrganisationForm(forms.ModelForm):
     new_form_intro = """<em>Note!</em> An organisation record is only needed to post news, events,
-products or professional services. In particular, it is <em>not</em> necessary to register an
+products, professional services or contributor badges. In particular, it is <em>not</em> necessary to register an
 organisation in order to ask questions or otherwise participate on the PostgreSQL mailing lists, file a bug
 report, or otherwise interact with the community."""
 
diff --git a/pgweb/urls.py b/pgweb/urls.py
index ce73c951..8bbdfdf6 100644
--- a/pgweb/urls.py
+++ b/pgweb/urls.py
@@ -73,9 +73,13 @@ urlpatterns = [
 
     re_path(r'^community/$', pgweb.core.views.community),
     re_path(r'^community/contributors/$', pgweb.contributors.views.completelist),
+    re_path(r'^community/people/$', pgweb.contributors.views.peoplelist),
+    re_path(r'^community/people/([^/]+)/$', pgweb.contributors.views.profile),
+    re_path(r'^community/badge/(\d+)/$', pgweb.contributors.views.badge_view),
+    re_path(r'^community/badge/(\d+)/image/$', pgweb.contributors.views.badge_image),
+
     re_path(r'^community/lists/$', RedirectView.as_view(url='/list/', permanent=True)),
     re_path(r'^community/lists/subscribe/$', RedirectView.as_view(url='https://lists.postgresql.org/', permanent=True)),
-
     re_path(r'^community/lists/listinfo/$', pgweb.lists.views.listinfo),
     re_path(r'^community/recognition/$', RedirectView.as_view(url='/about/policies/', permanent=True)),
     re_path(r'^community/survey/vote/(\d+)/$', pgweb.survey.views.vote),
diff --git a/pgweb/util/contexts.py b/pgweb/util/contexts.py
index ca493714..dc60740d 100644
--- a/pgweb/util/contexts.py
+++ b/pgweb/util/contexts.py
@@ -47,6 +47,7 @@ sitenav = {
     'community': [
         {'title': 'Community', 'link': '/community/'},
         {'title': 'Contributors', 'link': '/community/contributors/'},
+        {'title': 'People', 'link': '/community/people/'},
         {'title': 'Mailing Lists', 'link': '/list/'},
         {'title': 'IRC', 'link': '/community/irc/'},
         # {'title': 'Slack', 'link': 'https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA'},
@@ -86,6 +87,7 @@ sitenav = {
             {'title': 'Events', 'link': '/account/edit/events/'},
             {'title': 'Products', 'link': '/account/edit/products/'},
             {'title': 'Professional Services', 'link': '/account/edit/services/'},
+            {'title': 'Contributor Badges', 'link': '/account/edit/badges/'},
             {'title': 'Organisations', 'link': '/account/edit/organisations/'},
         ]},
         {'title': 'Change password', 'link': '/account/changepwd/'},
diff --git a/pgweb/util/moderation.py b/pgweb/util/moderation.py
index 1d9a2e3c..2c054800 100644
--- a/pgweb/util/moderation.py
+++ b/pgweb/util/moderation.py
@@ -160,7 +160,8 @@ def _modclasses():
     from pgweb.core.models import Organisation
     from pgweb.downloads.models import Product
     from pgweb.profserv.models import ProfessionalService
-    return [NewsArticle, Event, Organisation, Product, ProfessionalService]
+    from pgweb.contributors.models import Badge
+    return [NewsArticle, Event, Organisation, Product, ProfessionalService, Badge]
 
 
 def get_all_pending_moderations():
diff --git a/templates/account/index.html b/templates/account/index.html
index 94761e00..4fb74a2e 100644
--- a/templates/account/index.html
+++ b/templates/account/index.html
@@ -5,8 +5,9 @@
 <p>
 From this section, you can manage all information on this site connected
 to your PostgreSQL community account. Other than your basic profile
-information, this includes news and events, professional services and
-comments. Note that most of the data you submit to this site needs to be
+information, this includes news and events, professional services,
+comments and contributor badges.
+Note that most of the data you submit to this site needs to be
 approved by a moderator before it's published.
 </p>
 <p>
@@ -16,7 +17,7 @@ type of record in the menu on the left.
 
 <h2>Permissions model</h2>
 <p>
-News, Events, Products and Professional Services are attached to an
+News, Events, Products, Professional Services and Contributor Badges are attached to an
 Organisation. One or more persons can be given permissions to manage
 the data for an organisation. If you do not have permissions for an
 organisation and think you should have, you need to contact the current
diff --git a/templates/account/userprofileform.html b/templates/account/userprofileform.html
index 96ed2779..c6c49e52 100644
--- a/templates/account/userprofileform.html
+++ b/templates/account/userprofileform.html
@@ -94,9 +94,23 @@
       </div>
     {%endfor%}
 
+  {% if badges %}
+    <h2>Contributor badges</h2>
+    <div class="badges-grid">
+    {%for b in badges %}
+      <figure class="badges-cell">
+        <a href="/community/badge/{{b.id}}/">
+        <img src="{%if b.imagedata %}/community/badge/{{b.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
+        <figcaption>{{b}}</figcaption>
+        </a>
+      </figure>
+    {%endfor%}
+    </div>
+  {% endif %}
+
   {% if contribform %}
     <h2>Edit contributor information</h2>
-    <p>You can edit the information that's shown on the <a href="/community/contributors/" target="_blank" rel="noopener">contributors</a> page. Please be careful as your changes will take effect immediately!
+    <p>You can edit the information that's shown on your <a href="/community/people/{{user.username}}/" target="_blank" rel="noopener">profile</a> page. Leave fields blank that you do not wish to make public. Please be careful as your changes will take effect immediately!
     </p>
     {% for field in contribform %}
       <div class="form-group row">
@@ -116,6 +130,12 @@
         </div>
       </div>
     {% endfor %}
+  {% elif badges %}
+    <h2>Create contributor profile</h2>
+    <p>You have been awarded contributor badges. To have your name included in the public people and badge holder lists, you can create a publicly visible profile page.</p>
+    <div>
+      <input type="checkbox" name="create_contributor_profile"> Create publicly visible profile page
+    </div>
   {% endif %}
 
   <div class="submit-row">
diff --git a/templates/base/form_contents.html b/templates/base/form_contents.html
index 9a00c216..4f9b4263 100644
--- a/templates/base/form_contents.html
+++ b/templates/base/form_contents.html
@@ -1,5 +1,5 @@
 {%load pgfilters%}
-<form class="form-horizontal" method="post" action=".">{%if not nocsrf%}{% csrf_token %}{%endif%}
+<form class="form-horizontal" method="post" action="."{% if form.is_multipart %} enctype="multipart/form-data"{% endif %}>{%if not nocsrf%}{% csrf_token %}{%endif%}
   {%if form.errors %}
     <div class="alert alert-danger">
       Please correct the errors below, and re-submit the form.
diff --git a/templates/contributors/badge.html b/templates/contributors/badge.html
new file mode 100644
index 00000000..0e2cee40
--- /dev/null
+++ b/templates/contributors/badge.html
@@ -0,0 +1,49 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{badge}} - Contribution{%endblock%}
+{%block contents%}
+<h1>Badge: {{badge}} <i class="fa fa-star"></i></h1>
+<p><img src="{%if badge.imagedata %}/community/badge/{{badge.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"></p>
+
+{% if badge.description or badge.url %}
+<h2>Contribution</h2>
+{%endif%}
+{% if badge.description %}
+<p>{{badge.description}}</p>
+{%endif%}
+{% if badge.url %}
+<p><a href="{{badge.url}}">{{badge.url}}</a></p>
+{%endif%}
+
+{%if badge.holders%}
+<h2>Badge Holders</h2>
+  {%if holders|length > 50%}
+    {%for u in holders.all %}
+      <nobr>{%if u.date_retired%}<em>{%endif%}<a href="/community/people/{{u.user}}/">{{u.user.first_name}} {{u.user.last_name}}</a>{%if u.date_retired%}</em>{%endif%} {%if not forloop.last%}&middot;{%endif%}</nobr>
+    {%endfor%}
+  {%else%}
+    <ul>
+      {%for u in holders.all%}
+        <li>{%if u.date_retired%}<em>{%endif%}<a href="/community/people/{{u.user}}/">{{u.user.first_name}} {{u.user.last_name}}</a>{%if u.date_retired%} (retired from this activity)</em>{%endif%}</li>
+      {%endfor%}
+    </ul>
+  {%endif%}
+{%endif%}
+
+<h2>Getting added</h2>
+{% if badge.org %}
+<p>This badge is issued by <em>{{badge.org}}</em>.</p>
+{%endif%}
+
+<p>If you think you should be listed
+  here, check your <a href="/account/profile/">account profile</a> page if the
+  badge is present there and activate your contribution profile page. If not,
+  contact the badge issuing organisation and ask to be added.</p>
+
+{% if badge.contact %}
+<p>Contact information: <em>{{badge.contact}}</em>.</p>
+{%endif%}
+
+<p>People are listed in alphabetical order.</p>
+
+{%endblock%}
diff --git a/templates/contributors/list.html b/templates/contributors/list.html
index 068fc949..8377a8e7 100644
--- a/templates/contributors/list.html
+++ b/templates/contributors/list.html
@@ -1,18 +1,18 @@
 {%extends "base/page.html"%}
 {%load pgfilters%}
-{%block title%}Contributor Profiles{%endblock%}
+{%block title%}Recognized Contributors{%endblock%}
 {%block contents%}
-<h1>PostgreSQL Contributor Profiles <i class="fa fa-users"></i></h1>
-
-<p>These are the fine people that make PostgreSQL what it is today!</p>
+<h1>PostgreSQL Recognized Contributors <i class="fa fa-users"></i></h1>
 
 <p>
-    For a list of all code contributions to a specific release, see the
-  <a href="/docs/release/">Release Notes</a> for released versions of PostgreSQL.
+  These are the fine people who make PostgreSQL what it is today!
+  Contributors listed here have provided a sustained stream of notable contributions in recent years.
 </p>
+
 <p>
-    Existing contributors can update their information in their
-    <a href="/account/profile/">user profile</a>.
+  A separate page lists all
+  <a href="/community/people/">people who have made contributions to PostgreSQL</a>
+  over the years.
 </p>
 
 {%for t in contributortypes%}
@@ -32,7 +32,7 @@
     {%for c in t.contributor_set.all %}
      {%if t.detailed%}
       <tr>
-       <td>{{c.firstname}} {{c.lastname}} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
+       <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
           {%if c.company %}
           <br/>
             {% if c.companyurl %}
@@ -41,21 +41,23 @@
               {{c.company}}
             {% endif %}
           {% endif %}
-          <br/>
-          {{c.location}}
+          {%if c.location %}
+            <br/>
+            {{c.location}}
+          {% endif %}
         </td>
         <td>{{c.contribution}}</td>
        </tr>
      {%else%}
       {%if forloop.counter0|divisibleby:"2" %}
        <tr>
-        <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+        <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
         {%if forloop.last%}
         <td></td>
        </tr>
         {%endif%}
       {%else%}
-        <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+        <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
        </tr>
       {%endif%}
      {%endif%}
@@ -65,6 +67,7 @@
 {%endfor%}
 
 <p>All contributors are listed in alphabetical order.
+  Existing contributors can update their information in their <a href="/account/profile/">user profile</a>.
   To suggest additions or corrections to this list, please email the
   <a href="/about/governance/contributors/">Contributors Committee</a> at
   <a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
diff --git a/templates/contributors/people.html b/templates/contributors/people.html
new file mode 100644
index 00000000..ce192cd7
--- /dev/null
+++ b/templates/contributors/people.html
@@ -0,0 +1,36 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}People in the PostgreSQL Community{%endblock%}
+{%block contents%}
+<h1>People in the PostgreSQL Community <i class="fa fa-users"></i></h1>
+<p>These are the fine people who have made contributions to PostgreSQL over the years.</p>
+
+<p>Recognized Significant and Major Contributors are listed on the
+ <a href="/community/contributors/">Contributors page</a>.
+</p>
+
+<p style="text-align: justify;">
+  {%for c in people %}
+    <nobr>{%if c.user%}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{%else%}{{c.firstname}} {{c.lastname}}{%endif%} {%if not forloop.last%}&middot;{%endif%}</nobr>
+  {%endfor%}
+</p>
+
+<p>People are listed in alphabetical order.
+  If you are listed and want to claim your profile page, write a mail including your community account name to
+  <a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
+</p>
+
+<h1>Contribution Badges</h1>
+
+<div class="badges-grid">
+{%for b in badges %}
+    <figure class="badges-cell">
+        <a href="/community/badge/{{b.id}}/">
+        <img src="{%if b.imagedata %}/community/badge/{{b.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
+        <figcaption>{{b}}</figcaption>
+        </a>
+    </figure>
+{%endfor%}
+</div>
+
+{%endblock%}
diff --git a/templates/contributors/profile.html b/templates/contributors/profile.html
new file mode 100644
index 00000000..fc2eaabc
--- /dev/null
+++ b/templates/contributors/profile.html
@@ -0,0 +1,57 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{contributor.firstname}} {{contributor.lastname}} - Contributor Profile{%endblock%}
+{%block contents%}
+<h1>{{contributor.firstname}} {{contributor.lastname}} <i class="fa fa-user"></i></h1>
+
+<div class="row">
+  <div class="col-md-8">
+    {%if badges%}
+    <h2>Contribution Badges</h2>
+      <div class="badges-grid">
+      {%for b in badges %}
+        <figure class="badges-cell">
+          <a href="/community/badge/{{b.badge.id}}/">
+          <img src="{%if b.badge.imagedata %}/community/badge/{{b.badge.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
+          <figcaption>{{b.badge}}{%if b.date_retired%}<br><em>(retired)</em>{%endif%}</figcaption>
+          </a>
+        </figure>
+      {%endfor%}
+      </div>
+    {%endif%}
+
+    {%if contributor.contribution%}
+    <h2>Contribution</h2>
+    <p>{{contributor.contribution}}</p>
+    {%endif%}
+
+    <h2>Information</h2>
+    <dl class="row">
+      <dt class="col-sm-3">Contributor Type</dt>
+      <dd class="col-sm-9">{%if contributor.ctype%}{{contributor.ctype.typename}}{%else%}Basic contributor, not recognized{%endif%}</dd>
+
+      {%if contributor.email%}
+      <dt class="col-sm-3">Email</dt>
+      <dd class="col-sm-9">{{contributor.email|hidemail}}</dd>
+      {%endif%}
+
+      {%if contributor.location%}
+      <dt class="col-sm-3">Location</dt>
+      <dd class="col-sm-9">{{contributor.location}}</dd>
+      {%endif%}
+
+      {%if contributor.company%}
+      <dt class="col-sm-3">Company</dt>
+      <dd class="col-sm-9">
+        {%if contributor.companyurl%}
+          <a href="{{contributor.companyurl}}" target="_blank" rel="noopener">{{contributor.company}}</a>
+        {%else%}
+          {{contributor.company}}
+        {%endif%}
+      </dd>
+      {%endif%}
+    </dl>
+  </div>
+</div>
+
+{%endblock%}
-- 
2.53.0

